Repository navigation
feat(shadow-requests): add experimental shadow_config wrapper - #250
Conversation
… when they can't be prepared
XieX
left a comment
There was a problem hiding this comment.
Nice approach. Core never calls out to shadow code, and plain config() runs the same path as before: the client suite passes locally with no existing test edits. The new guard around _select_tasks covers the one robustness gap I'd noticed. Three small, non-blocking comments inline.
| class _Evaluated(_InternalConfigInstance): | ||
| """Runs ``config()``'s invoke and stream on a variation that is already evaluated.""" | ||
|
|
||
| def __init__(self, source: ConfigInstance, variation: dict[str, Any]) -> None: |
There was a problem hiding this comment.
_Evaluated forwards ConfigInstance's constructor fields by hand. If ConfigInstance gains an option later, the served run under shadow_config() would silently run without it, and its output would stop matching config(). The parity tests wouldn't catch that, since they only exercise today's options.
A small test comparing the parameters of ConfigInstance.__init__ with the fields forwarded here would make that drift fail loudly.
| return self._variation | ||
|
|
||
|
|
||
| async def _evaluate(config_key: str, context: LDContext) -> _Evaluation: |
There was a problem hiding this comment.
This repeats the client half of extract_variation (init_client, get_client, to_ld_context and the awaitable handling), so a later change there, such as to lazy init or context conversion, won't reach shadow_config().
Could core have a private helper that fetches the raw value, with a generic with_reason flag, called by both extract_variation and this function? It doesn't name the feature, so core stays unaware of shadows.
| primary._execute_stream(user_input, context, variables, history, sink) | ||
| ) as source: | ||
| async for event in source: | ||
| if event.get("type") == "done": |
There was a problem hiding this comment.
Minor: when streaming, shadows start here, before judges run (_stream_events runs them after this generator finishes). On invoke they start after _invoke returns, which is after run_judges has finished. Neither delays the caller, but with slow judges, invoke shadows start noticeably later than stream ones. Fine if intended; a short comment saying so would help.
XieX
left a comment
There was a problem hiding this comment.
One more finding from cross-checking against the JS SDK, inline below.
| usage=parse_usage({}), track_data=task.track_data, error="timeout" | ||
| ) | ||
| except Exception as err: | ||
| return ShadowResult( |
There was a problem hiding this comment.
On failure, ShadowResult.track_data is task.track_data, which has only the six shadow fields (runId, isShadow, primaryVariationKey, shadowRequestId, ownerKind, ownerId). There is no configKey, variationKey, version, or model and provider names. That applies here, on the timeout path above, and when no handler matches (select_handler raises inside the try). On success it's B's full tracking data. So a worker running deferred tasks can't tell from the result which variation failed.
The JS SDK returns B's full tracking data on every path (makeRunTrackData plus the shadow fields). Here it would need the same payload execute_and_track builds inline. A small private helper in tracking.py, used by both, would keep them from drifting. The tests at lines 749 and 766 only check isShadow, so asserting configKey and variationKey on a failed result would cover it.
Minor, if you want the SDKs aligned: the no-handler error reads "Handler for provider X not found" here and "No handler for provider X" in JS.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 17a4487. Configure here.
|
These have been fixed, and I had Claude confirm. Christie, could you take a look next week? From Claude: Fixed Christie's comments in 17a4487 and 082f1cf, merged with main in c90a6ec.
The no-handler message stays core's existing error. |
jeffdupont
left a comment
There was a problem hiding this comment.
Nice work. The four open threads from Christie all look addressed at this head and can be resolved. On semver: the only root API change is four new keyword-only, optional parameters on execute_and_track, and griffe finds no breaking changes against main. config() still calls only variation(), and plain AG-UI streams are unchanged. Locally at c90a6ec, these all pass: pytest packages/client (2093 passed), mypy, ruff check, ruff format --check and scripts/api_report.py --check.
Two small runtime fixes are inline. I'd like the first one before merge, because it can record a timed-out shadow as a success.
| async def timed(*args: Any) -> Any: | ||
| deadline = asyncio.timeout(timeout_s) | ||
| try: | ||
| async with deadline: |
There was a problem hiding this comment.
Please check deadline.expired() after the async with block too. asyncio.timeout only raises TimeoutError if the CancelledError propagates. A handler (or a provider client) that catches the cancellation for cleanup and then returns a value exits the block normally, and its late result is tracked as generation:success. A result that arrives after the timeout should be discarded and tracked as a timeout:
async with deadline:
result = await handler(*args)
if deadline.expired():
raise _ShadowTimeout("timeout")
return resultI confirmed the asyncio.timeout behaviour on its own, but didn't run it end to end through run_shadow. A test with a handler that swallows CancelledError would pin it down.
| except _ShadowTimeout: | ||
| return _failed(task, "timeout") | ||
| except Exception as err: | ||
| return _failed(task, str(err)) |
There was a problem hiding this comment.
When the exception has no message (a bare TimeoutError() is common from HTTP clients), str(err) is "", so a worker that checks if result.error treats the failure as a success. Suggest str(err) or type(err).__name__.
| assert sr._running | ||
| await shutdown() | ||
|
|
||
| async def test_failing_shadow_never_reaches_the_caller(self) -> None: |
There was a problem hiding this comment.
Optional: this test and the timed-out test below only check that generation:success appears among the served call's events. Comparing them to the events from the same call with no shadow configured would prove the shadow left them unchanged.
…rn an empty error
|
Thanks Jeff, all fixed. A result after the deadline now counts as a timeout, and an empty error uses the type name. |
jeffdupont
left a comment
There was a problem hiding this comment.
Thanks, approving. Both fixes are in at d083e5b. A handler that swallows the cancellation and returns late is now a timeout (tracked as one error and no success), and an exception with no message reports its type name. Both are tested. The rate-1 rule is in too.
Locally at this head, these all pass: pytest packages/client (2096 passed), mypy and ruff. I also checked with a throwaway test that a stream abandoned before done and a failed served call with on_task set both start no shadow and hand over no task. Optional: tests for those two cases would lock that behaviour in. My earlier optional note on the failing-shadow tests still stands, but it isn't needed for merge.
My threads can be resolved.
…lformed shadow tools

Why
Shadow requests let a team try a candidate AI Config variation on real traffic without serving it, and compare it on the normal AI metrics. The SDK owners asked for this to be merged as experimental (TESTING.md §0). Earlier versions (#241, #247) either added an invocation hook to core or changed
config()for everyone; this one is an opt-in wrapper, so core behavior is unchanged.Summary
Adds
launchdarkly_ai_server.experimental.shadow_requestswithshadow_config(),run_shadow(),ShadowTaskandShadowResult.shadow_config()takes the same arguments asconfig(), evaluates the flag once withvariation_detail, runs the served variation through the normalconfig()path, and starts the sampled shadows after it succeeds (in the background, or throughon_task). Core gets three small generic seams: a privateConfigInstance._evaluate, a private_parse_variationsplit out ofextract_variation, and optionalrun_id,extra_track_dataandspan_attributesonexecute_and_track. Also adds the API report, a README section and theshadow-requestschangelog scope.Testing approaches
config()never callsvariation_detailand runs no shadows, even when the flag has a shadow configuration.config()for invoke, stream and the AG-UI stream.Feature flags
N/A
Note
Overview
Adds experimental shadow requests under
launchdarkly_ai_server.experimental.shadow_requests:shadow_config()mirrorsconfig()but, after the served variation succeeds, runs sampled candidate variations from flagshadowConfiguration(rule/fallthrough slot, expiry, sticky sampling). Responses are discarded; telemetry reuses the primaryrunIdwithisShadowand related fields. Shadows run in the background (30s default, cancelled onshutdown()) or viaon_task+run_shadow()for worker queues.Plain
config()is unchanged—no shadows and novariation_detail. Core changes are small shared hooks:_fetch_variation/_parse_variation,ConfigInstance._evaluateand_judge_stream(stream/AG-UI start shadows after judges),make_track_data, and optionalrun_id,extra_track_data,span_attributes, andfeature_flag_event=Falseonexecute_and_track. Docs,shadow-requestschangelog scope, and a new API report accompany the module plus a largetest_shadow_requests.pysuite.Reviewed by Cursor Bugbot for commit 133a542. Bugbot is set up for automated code reviews on this repo. Configure here.